// ==UserScript==
// @name               Twitter / X 浏览助手
// @name:zh-CN         Twitter / X 浏览助手
// @name:en            Twitter / X Enhancer Tools
// @description        增强功能：下载图片、下载视频、推文一键截图。
// @description:zh-CN  增强功能：下载图片、下载视频、推文一键截图。
// @description:en     Enhanced Features: Download Picture, Download Video, One-click screenshot for tweets.
// @namespace          https://www.runningcheese.com/userscripts
// @author             RunningCheese
// @version            1.1
// @match              https://twitter.com/*
// @match              https://x.com/*
// @icon               https://t1.gstatic.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&size=32&url=https://x.com
// @grant              GM_setValue
// @grant              GM_getValue
// @grant              GM_download
// @grant              GM_addStyle
// @license            MIT
// @require      https://cdn.jsdelivr.net/npm/html-to-image@1.11.11/dist/html-to-image.js
// @downloadURL https://update.greasyfork.org/scripts/577679/Twitter%20%20X%20%E6%B5%8F%E8%A7%88%E5%8A%A9%E6%89%8B.user.js
// @updateURL https://update.greasyfork.org/scripts/577679/Twitter%20%20X%20%E6%B5%8F%E8%A7%88%E5%8A%A9%E6%89%8B.meta.js
// ==/UserScript==




class Config {
    static AUTH_TOKEN = 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
    static filenamePattern = '{user-name}(@{user-id})_{index}';

    static media_btn_css = `
        .tmd-down {margin-left: 12px; order: 99; position: relative;}
        .tmd-down:hover > div > div > div > div {color: #333;}
        .tmd-down:hover > div > div > div > div > div {background-color: rgba(51, 51, 51, 0.1);}
        .tmd-down:active > div > div > div > div > div {background-color: rgba(51, 51, 51, 0.2);}
        .tmd-down:hover svg {color: #333;}
        .tmd-down:hover div:first-child:not(:last-child) {background-color: rgba(51, 51, 51, 0.1);}
        .tmd-down:active div:first-child:not(:last-child) {background-color: rgba(51, 51, 51, 0.2);}
        .tmd-down.tmd-media {position: absolute; right: 0;}
        .tmd-down.tmd-media > div {display: flex; border-radius: 99px; margin: 2px;}
        .tmd-down.tmd-media > div > div {display: flex; margin: 6px; color: #333;}
        .tmd-down.tmd-media:hover > div {background-color: rgba(255,255,255, 0.6);}
        .tmd-down.tmd-media:hover > div > div {color: #333;}
        .tmd-down.tmd-media:not(:hover) > div > div {filter: drop-shadow(0 0 1px #000);}
        .tmd-down g {display: none;}
        .tmd-down.download g.download, .tmd-down.completed g.completed, .tmd-down.exist g.completed, .tmd-down.loading g.loading,.tmd-down.failed g.failed {display: unset;}
        .tmd-down.exist svg {color: #FFD700;}
        .tmd-down.loading svg {animation: spin 1s linear infinite; color: #FFD700;}
        @keyframes spin {0% {transform: rotate(0deg);} 100% {transform: rotate(360deg);}}
        @keyframes tmd-pop-anim {
            0% { transform: scale(0); opacity: 0; }
            50% { transform: scale(1.3); opacity: 1; }
            100% { transform: scale(1); }
        }
        @keyframes tmd-burst-anim {
            0% {
                box-shadow:
                    0 -10px 0 0 #00ba7c, 7px -7px 0 0 #FFD700,
                    10px 0 0 0 #00ba7c,  7px 7px 0 0 #FFD700,
                    0 10px 0 0 #00ba7c,  -7px 7px 0 0 #FFD700,
                    -10px 0 0 0 #00ba7c, -7px -7px 0 0 #FFD700;
                opacity: 1;
                transform: translate(-50%, -50%) scale(0.5);
            }
            100% {
                box-shadow:
                    0 -25px 0 0 #00ba7c, 18px -18px 0 0 #FFD700,
                    25px 0 0 0 #00ba7c,  18px 18px 0 0 #FFD700,
                    0 25px 0 0 #00ba7c,  -18px 18px 0 0 #FFD700,
                    -25px 0 0 0 #00ba7c, -18px -18px 0 0 #FFD700;
                opacity: 0;
                transform: translate(-50%, -50%) scale(1.2);
            }
        }
        .tmd-down.completed svg {
            color: #00ba7c;
            animation: tmd-pop-anim 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
        }
        .tmd-down.completed:hover div:first-child:not(:last-child) {
            background-color: rgba(0, 186, 124, 0.2);
        }
        .tmd-down.completed::after {
            content: '';
            position: absolute;
            top: 50%;
            left: 50%;
            width: 4px;
            height: 4px;
            border-radius: 50%;
            transform: translate(-50%, -50%);
            animation: tmd-burst-anim 0.6s ease-out forwards;
            pointer-events: none;
        }
        .tmd-down.tmd-img {position: absolute; right: 0; bottom: 0; display: none !important;}
        .tmd-down.tmd-img > div {display: flex; border-radius: 99px; margin: 2px; background-color: rgba(255,255,255, 0.6);}
        .tmd-down.tmd-img > div > div {display: flex; margin: 6px; color: #333 !important;}
        .tmd-down.tmd-img:hover > div > div {color: #FFD700;}
        :hover > .tmd-down.tmd-img, .tmd-img.loading, .tmd-img.completed, .tmd-img.exist, .tmd-img.failed {display: block !important;}
        .tweet-detail-action-item {width: 20% !important;}
    `;

    static svg = `
        <g class="download">
            <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/>
            <path d="M8 12l4 4 4-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
            <path d="M12 8v8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
        </g>
        <g class="completed">
            <circle cx="12" cy="12" r="10" fill="currentColor" stroke="none"/>
            <polyline points="8 12 11 14.5 16.5 9" fill="none" stroke="#333" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
        </g>
        <g class="loading">
            <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" opacity="0.3"/>
            <path d="M12 2a10 10 0 0 1 10 10" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
        </g>
        <g class="failed">
            <circle cx="12" cy="12" r="10" fill="currentColor" stroke="none"/>
            <path d="M9 9l6 6M15 9l-6 6" fill="none" stroke="#333" stroke-width="2" stroke-linecap="round"/>
        </g>
    `;

    static snap_btn_css = `
        .tmd-snap {margin-left: 12px; order: 99; position: relative;}
        .tmd-snap:hover svg {color: #333;}
        .tmd-snap:hover div:first-child:not(:last-child) {background-color: rgba(51, 51, 51, 0.1);}
        .tmd-snap:active div:first-child:not(:last-child) {background-color: rgba(51, 51, 51, 0.2);}
        .tmd-snap.snapping svg {color: #1d9bf0;}
    `;

    static snap_svg = `
        <g class="snap">
            <path d="M9 3h6l2 4h4a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h4l2-4z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
            <circle cx="12" cy="13" r="3" fill="none" stroke="currentColor" stroke-width="2"/>
        </g>
    `;
}

class ScreenshotCapture {
    static ATTR = 'data-snap-btn';
    static BAR_SEL = 'div[role="group"]';

    // ─── Canvas helpers ──────────────────────────────────────────────────
    static loadImg(url) {
        return new Promise((ok, no) => {
            const I = new Image(); I.onload = () => ok(I); I.onerror = no; I.src = url;
        });
    }

    static trimEdge(I) {
        const W = I.width, H = I.height;
        const C = document.createElement('canvas');
        C.width = W; C.height = H;
        const G = C.getContext('2d');
        G.drawImage(I, 0, 0);
        const P = G.getImageData(0, 0, W, H).data;
        const blank = (x, y) => { const i = (y * W + x) << 2; return P[i + 3] < 8 || (P[i] > 245 && P[i + 1] > 245 && P[i + 2] > 245); };
        let T = 0, B = H - 1, L = 0, R = W - 1, ok = false;
        top: for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) if (!blank(x, y)) { T = y; ok = true; break top; }
        if (!ok) return { left:0, top:0, width:W, height:H };
        bot: for (let y = H - 1; y >= 0; y--) for (let x = 0; x < W; x++) if (!blank(x, y)) { B = y; break bot; }
        lef: for (let x = 0; x < W; x++) for (let y = T; y <= B; y++) if (!blank(x, y)) { L = x; break lef; }
        rig: for (let x = W - 1; x >= 0; x--) for (let y = T; y <= B; y++) if (!blank(x, y)) { R = x; break rig; }
        return { left:L, top:T, width: R - L + 1, height: B - T + 1 };
    }

    // ─── DOM helpers ─────────────────────────────────────────────────────
    static findRemov(card) {
        const set = new Set();
        card.querySelectorAll(this.BAR_SEL).forEach(n => {
            set.add(n);
            let sib = n.nextElementSibling;
            while (sib) { set.add(sib); sib = sib.nextElementSibling; }
            let prev = n.previousElementSibling;
            while (prev) {
                const hasStats = prev.querySelector('a[href*="/analytics"], a[href*="/retweets"], a[href*="/likes"], a[href*="/quotes"]');
                const isFooter = prev.querySelector('time') && !prev.querySelector('img, video, [data-testid="tweetText"], [data-testid="tweetPhoto"]');
                if (hasStats || isFooter) { set.add(prev); } else { break; }
                prev = prev.previousElementSibling;
            }
        });
        card.querySelectorAll('[data-testid$="-follow"], [data-testid$="-unfollow"]').forEach(n => set.add(n));
        card.querySelectorAll('aside, [data-testid*="related"], [data-testid*="Related"], [aria-label*="相关"], [aria-label*="Relevant"], [aria-label*="推荐"]').forEach(n => set.add(n));
        for (const el of card.querySelectorAll('div, section, span')) {
            if (el.children.length === 0 && /^\s*相关/.test(el.textContent || '')) {
                const row = el.closest('div, section');
                if (row && row !== card && row.parentElement) set.add(row);
            }
        }
        card.querySelectorAll('a[href*="/quotes"]').forEach(n => set.add(n));
        card.querySelectorAll('a[href*="/analytics"], a[href*="/retweets"], a[href*="/likes"]').forEach(n => set.add(n));
        for (const el of card.querySelectorAll('[aria-hidden="true"]')) {
            if ((el.textContent || '').trim() === '·') set.add(el);
        }
        return [...set].filter(n => n.parentNode);
    }

    static hideNodes(nodes) {
        const undo = [];
        for (const n of nodes) {
            const m = document.createComment('tmd-cap');
            n.replaceWith(m);
            undo.push(() => m.replaceWith(n));
        }
        return () => undo.forEach(fn => fn());
    }

    // ─── Cross-origin fix ────────────────────────────────────────────────
    static dataUri(url) {
        return fetch(url, { mode:'cors' })
            .then(r => r.blob())
            .then(b => new Promise((ok, no) => { const r = new FileReader(); r.onload = () => ok(r.result); r.onerror = no; r.readAsDataURL(b); }));
    }

    static async safeImgs(el) {
        const undo = [], jobs = [];
        for (const n of [el, ...el.querySelectorAll('*')]) {
            if (/^(IMG|SOURCE|VIDEO|image)$/i.test(n.tagName) && n.src && !n.src.startsWith('data:')) {
                const orig = n.src;
                undo.push(() => n.src = orig);
                jobs.push(this.dataUri(orig).then(u => n.src = u).catch(() => {}));
            }
            const bg = getComputedStyle(n).backgroundImage;
            if (bg && bg !== 'none') {
                const re = /url\(\s*["']?\s*(https?:\/\/[^"'\s)]+)\s*["']?\s*\)/gi;
                const urls = [...bg.matchAll(re)].map(m => m[1]);
                if (urls.length) {
                    const orig = n.style.backgroundImage;
                    undo.push(() => n.style.backgroundImage = orig);
                    jobs.push(
                        Promise.all(urls.map(u => this.dataUri(u).then(d => [u, d]).catch(() => [u, u])))
                            .then(pairs => { let v = bg; for (const [f, t] of pairs) v = v.replace(f, t); n.style.backgroundImage = v; })
                    );
                }
            }
        }
        await Promise.all(jobs);
        return () => undo.forEach(fn => fn());
    }

    // ─── Capture pipeline ────────────────────────────────────────────────
    static async capture(el, shouldTrim) {
        const restore = await this.safeImgs(el);
        try {
            const opts = {
                pixelRatio: 3, backgroundColor:'#ffffff', skipFonts:true,
                filter: node => !node || (!node.hasAttribute?.(this.ATTR) && node.tagName?.toLowerCase() !== 'noscript'),
            };
            let uri;
            try { uri = await htmlToImage.toPng(el, opts); }
            catch (_) { uri = await htmlToImage.toSvg(el, opts); }
            const I = await this.loadImg(uri);
            let box = { left:0, top:0, width:I.width, height:I.height };
            if (shouldTrim !== false) {
                const T = document.createElement('canvas');
                T.width = I.width; T.height = I.height;
                T.getContext('2d').drawImage(I, 0, 0);
                try { T.getContext('2d').getImageData(0, 0, 1, 1); box = this.trimEdge(I); } catch (_) {}
            }
            const C = document.createElement('canvas');
            C.width = box.width; C.height = box.height;
            C.getContext('2d').drawImage(I, box.left, box.top, box.width, box.height, 0, 0, box.width, box.height);
            return { source:C, width:box.width, height:box.height };
        } finally { restore(); }
    }

    static async collect(card) {
        return [await this.capture(card, true)];
    }

    // ─── Image composition ───────────────────────────────────────────────
    static async mergeImgs(assets) {
        const PAD = 50;
        if (assets.length === 1) {
            const a = assets[0];
            const C = document.createElement('canvas');
            C.width = a.width + PAD * 2;
            C.height = a.height + PAD * 2;
            const G = C.getContext('2d');
            G.fillStyle = '#ffffff';
            G.fillRect(0, 0, C.width, C.height);
            G.drawImage(a.source, PAD, PAD);
            return C.toDataURL('image/png');
        }
        const GAP = 84;
        const maxW = assets.reduce((m, a) => Math.max(m, a.width), 0);
        const bodyH = assets.reduce((s, a, i) => s + a.height + (i ? GAP : 0), 0);
        const W = maxW + PAD * 2, H = PAD + bodyH + PAD;
        const C = document.createElement('canvas');
        C.width = W; C.height = H;
        const G = C.getContext('2d');
        G.fillStyle = '#ffffff';
        G.fillRect(0, 0, W, H);
        let y = PAD;
        for (const a of assets) { G.drawImage(a.source, PAD, y, a.width, a.height); y += a.height + GAP; }
        return C.toDataURL('image/png');
    }

    // ─── Style fix ───────────────────────────────────────────────────────
    static fixMargin(card) {
        const undo = [];
        card.querySelectorAll('.r-6gpygo').forEach(el => {
            const orig = el.style.marginBottom;
            el.style.marginBottom = '0px';
            undo.push(() => el.style.marginBottom = orig);
        });
        return () => undo.forEach(fn => fn());
    }
}

class Utils {
    static getCookie() {
        return Object.fromEntries(document.cookie.split(';').filter(n => n.includes('=')).map(n => n.split('=').map(s => s.trim())));
    }

    static formatDate(i, o, tz) {
        let d = new Date(i);
        if (tz) d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
        let m = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
        let v = {
            YYYY: d.getUTCFullYear().toString(), YY: d.getUTCFullYear().toString(),
            MM: d.getUTCMonth() + 1, MMM: m[d.getUTCMonth()], DD: d.getUTCDate(),
            hh: d.getUTCHours(), mm: d.getUTCMinutes(), ss: d.getUTCSeconds(),
            h2: d.getUTCHours() % 12, ap: d.getUTCHours() < 12 ? 'AM' : 'PM'
        };
        return o.replace(/(YY(YY)?|MMM?|DD|hh|mm|ss|h2|ap)/g, n => ('0' + v[n]).slice(-n.length));
    }

    static getInvalidChars() {
        return {
            "\n": "　", "\t": "　", "\\": "⧹", "/": "⧸", "|": "｜", ":": "꞉", "*": "＊", "?": "？", '"': '″', "<": "＜", ">": "＞", '\u200b': '', '\u200c': '', '\u200d': '', '\u2060': '', '\ufeff': '', '🔞': ''
        };
    }
}

class StorageManager {
    async init() {
        let rawHistory = await GM_getValue('download_history', []);
        this.downloaded = new Set(rawHistory.map(item => typeof item === 'string' ? item : item.id));
    }

    isDownloaded(statusId) {
        return this.downloaded.has(statusId);
    }

    async markDownloaded(statusId) {
        if (!this.downloaded.has(statusId)) {
            this.downloaded.add(statusId);
            let history = await GM_getValue('download_history', []);
            history.push(statusId);
            await GM_setValue('download_history', history);
        }
    }
}

class TwitterAPI {
    static async fetchTweetJson(status_id) {
        const cookies = Utils.getCookie();
        let url = encodeURI(`https://${location.hostname}/i/api/graphql/2ICDjqPd81tulZcYrtpTuQ/TweetResultByRestId?variables=${JSON.stringify({
            tweetId: status_id,
            with_rux_injections: false,
            includePromotedContent: true,
            withCommunity: true,
            withQuickPromoteEligibilityTweetFields: true,
            withBirdwatchNotes: true,
            withVoice: true,
            withV2Timeline: true
        })}&features=${JSON.stringify({
            "articles_preview_enabled": true,
            "c9s_tweet_anatomy_moderator_badge_enabled": true,
            "communities_web_enable_tweet_community_results_fetch": false,
            "creator_subscriptions_quote_tweet_preview_enabled": false,
            "creator_subscriptions_tweet_preview_api_enabled": false,
            "freedom_of_speech_not_reach_fetch_enabled": true,
            "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
            "longform_notetweets_consumption_enabled": false,
            "longform_notetweets_inline_media_enabled": true,
            "longform_notetweets_rich_text_read_enabled": false,
            "premium_content_api_read_enabled": false,
            "profile_label_improvements_pcf_label_in_post_enabled": true,
            "responsive_web_edit_tweet_api_enabled": false,
            "responsive_web_enhance_cards_enabled": false,
            "responsive_web_graphql_exclude_directive_enabled": false,
            "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
            "responsive_web_graphql_timeline_navigation_enabled": false,
            "responsive_web_grok_analysis_button_from_backend": false,
            "responsive_web_grok_analyze_button_fetch_trends_enabled": false,
            "responsive_web_grok_analyze_post_followups_enabled": false,
            "responsive_web_grok_image_annotation_enabled": false,
            "responsive_web_grok_share_attachment_enabled": false,
            "responsive_web_grok_show_grok_translated_post": false,
            "responsive_web_jetfuel_frame": false,
            "responsive_web_media_download_video_enabled": false,
            "responsive_web_twitter_article_tweet_consumption_enabled": true,
            "rweb_tipjar_consumption_enabled": true,
            "rweb_video_screen_enabled": false,
            "standardized_nudges_misinfo": true,
            "tweet_awards_web_tipping_enabled": false,
            "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
            "tweetypie_unmention_optimization_enabled": false,
            "verified_phone_label_enabled": false,
            "view_counts_everywhere_api_enabled": true
        })}`);

        let headers = {
            'authorization': Config.AUTH_TOKEN,
            'x-twitter-active-user': 'yes',
            'x-twitter-client-language': cookies.lang || 'en'
        };

        if (cookies.ct0) headers['x-csrf-token'] = cookies.ct0;
        if (cookies.gt) headers['x-guest-token'] = cookies.gt;

        let res = await fetch(url, { headers });
        if (!res.ok) throw new Error("API Fetch Failed");

        let tweet_detail = await res.json();
        return tweet_detail.data?.tweetResult?.result?.tweet || tweet_detail.data?.tweetResult?.result;
    }
}

class DownloadQueue {
    constructor() {
        this.tasks = [];
        this.thread = 0;
        this.max_thread = 2;
    }

    add(task) {
        this.tasks.push(task);
        if (this.thread < this.max_thread) {
            this.thread++;
            this.next();
        }
    }

    async next() {
        if (this.tasks.length === 0) {
            this.thread--;
            return;
        }
        let task = this.tasks.shift();
        await this.start(task);
        this.next();
    }

    start(task) {
        return new Promise(resolve => {
            GM_download({
                url: task.url,
                name: task.name,
                onload: () => { task.onload(); resolve(); },
                onerror: r => { task.onerror(r); this.retry(task); resolve(); },
                ontimeout: r => { task.onerror(r); this.retry(task); resolve(); }
            });
        });
    }

    retry(task) {
        task.retry = (task.retry || 0) + 1;
        if (task.retry <= 2) {
            this.max_thread = 1;
            this.tasks.push(task);
        }
    }
}

class UIManager {
    constructor(app) {
        this.app = app;
    }

    injectCSS() {
        document.head.insertAdjacentHTML('beforeend', `<style>${Config.media_btn_css}${Config.snap_btn_css}</style>`);
    }

    setButtonStatus(btn, css, title) {
        if (css) {
            btn.classList.remove('download', 'completed', 'exist', 'loading', 'failed');
            btn.classList.add(css);
        }
        if (title) btn.title = title;
    }

    addScreenshotButton(article) {
        // 跳过嵌套推文
        if (article.parentElement?.closest('article[data-testid="tweet"]')) return;
        // 已加过
        if (article.querySelector('.tmd-snap')) return;

        const isStatusPage = /^\/\w+\/status\/\d+/.test(location.pathname);
        // 推文详情页只给主贴加按钮，主页/时间线所有帖子都加
        if (isStatusPage && article !== document.querySelector('article[data-testid="tweet"]')) return;

        let actionBar = article.querySelector('div[role="group"]');
        if (!actionBar) return;

        // 克隆最后一个操作按钮作为截图按钮的样式基础
        let btnWrappers = actionBar.children;
        let lastWrapper = btnWrappers[btnWrappers.length - 1];
        if (!lastWrapper) return;

        let btnSnap = lastWrapper.cloneNode(true);
        btnSnap.querySelector('button')?.removeAttribute('disabled');
        let svg = btnSnap.querySelector('svg');
        if (svg) svg.innerHTML = Config.snap_svg;
        btnSnap.classList.add('tmd-snap');
        btnSnap.setAttribute(ScreenshotCapture.ATTR, '1');
        btnSnap.title = '截图推文';

        lastWrapper.parentNode.insertBefore(btnSnap, lastWrapper.nextSibling);
        btnSnap.onclick = (e) => {
            e.preventDefault();
            e.stopPropagation();
            this.app.handleScreenshotClick(btnSnap, article);
        };
    }

    addButtonsToArticle(article) {
        if (article.dataset.detected) return;
        article.dataset.detected = 'true';

        this.addScreenshotButton(article);

        let media = article.querySelector('a[href*="/photo/1"], div[role="progressbar"], button[data-testid="playButton"], a[href="/settings/content_you_see"], div.media-image-container, div.media-preview-container, div[aria-labelledby]>div:first-child>div[role="button"][tabindex="0"]');
        if (media) {
            let status_id = article.querySelector('a[href*="/status/"]').href.split('/status/').pop().split('/').shift();
            let btn_share = Array.from(article.querySelector('div[role="group"]:last-of-type, ul.tweet-actions, ul.tweet-detail-actions').querySelectorAll(':scope>div>div, li.tweet-action-item>a, li.tweet-detail-action-item>a')).pop().parentNode;
            let btn_down = btn_share.cloneNode(true);

            btn_down.querySelector('button')?.removeAttribute('disabled');
            if (this.app.isTweetDeck) {
                btn_down.firstElementChild.innerHTML = `<svg viewBox="0 0 24 24" style="width: 18px; height: 18px;">${Config.svg}</svg>`;
                btn_down.firstElementChild.removeAttribute('rel');
                btn_down.classList.replace("pull-left", "pull-right");
            } else {
                btn_down.querySelector('svg').innerHTML = Config.svg;
            }

            let is_exist = this.app.storage.isDownloaded(status_id);
            this.setButtonStatus(btn_down, 'tmd-down');
            this.setButtonStatus(btn_down, is_exist ? 'exist' : 'download', is_exist ? '已下载' : '下载');

            btn_share.parentNode.insertBefore(btn_down, btn_share.nextSibling);
            btn_down.onclick = () => this.app.handleDownloadClick(btn_down, status_id, is_exist);
        }

        let imgs = article.querySelectorAll('a[href*="/photo/"]');
        if (imgs.length > 1) {
            let status_id = article.querySelector('a[href*="/status/"]').href.split('/status/').pop().split('/').shift();
            imgs.forEach(img => {
                let index = img.href.split('/status/').pop().split('/').pop();
                let is_exist = this.app.storage.isDownloaded(status_id);
                let btn_down = document.createElement('div');
                btn_down.innerHTML = `<div><div><svg viewBox="0 0 24 24" style="width: 20px; height: 20px;">${Config.svg}</svg></div></div>`;
                btn_down.classList.add('tmd-down', 'tmd-img');
                this.setButtonStatus(btn_down, 'download');
                img.parentNode.appendChild(btn_down);
                btn_down.onclick = e => {
                    e.preventDefault();
                    this.app.handleDownloadClick(btn_down, status_id, is_exist, index);
                };
            });
        }
    }

    addButtonsToMediaList(listitems) {
        listitems.forEach(li => {
            if (li.dataset.tmdDetected === 'true') return;
            let statusLink = li.querySelector('a[href*="/status/"]');
            if (!statusLink || !statusLink.href) return;
            li.dataset.tmdDetected = 'true';

            let status_id = statusLink.href.split('/status/').pop().split(/[\/\?#]/).shift();
            let is_exist = this.app.storage.isDownloaded(status_id);

            let btn_down = document.createElement('div');
            btn_down.innerHTML = `<div><div><svg viewBox="0 0 24 24" style="width: 18px; height: 18px;">${Config.svg}</svg></div></div>`;
            btn_down.classList.add('tmd-down', 'tmd-media');
            this.setButtonStatus(btn_down, is_exist ? 'exist' : 'download', is_exist ? '已下载' : '下载');

            li.appendChild(btn_down);
            btn_down.onclick = (e) => {
                e.preventDefault();
                e.stopPropagation();
                this.app.handleDownloadClick(btn_down, status_id, is_exist);
            };
        });
    }
}

class TwitterMediaDownloaderApp {
    constructor() {
        this.storage = new StorageManager();
        this.queue = new DownloadQueue();
        this.ui = new UIManager(this);
        this.isTweetDeck = location.hostname.includes('tweetdeck');
    }

    async init() {
        await this.storage.init();
        this.ui.injectCSS();

        new MutationObserver(ms => ms.forEach(m => m.addedNodes.forEach(node => {
            let article = node.tagName === 'ARTICLE' && node || node.tagName === 'DIV' && (node.querySelector('article') || node.closest('article'));
            if (article) this.ui.addButtonsToArticle(article);
            let listitems = node.tagName === 'LI' && node.getAttribute('role') === 'listitem' && [node] || node.tagName === 'DIV' && node.querySelectorAll('li[role="listitem"]');
            if (listitems) this.ui.addButtonsToMediaList(listitems);
        }))).observe(document.body, { childList: true, subtree: true });
    }

    async handleDownloadClick(btn, status_id, is_exist, index) {
        if (btn.classList.contains('loading')) return;
        this.ui.setButtonStatus(btn, 'loading');

        let out = Config.filenamePattern;

        let tweet;
        try {
            tweet = await TwitterAPI.fetchTweetJson(status_id);
        } catch (e) {
            this.ui.setButtonStatus(btn, 'failed', 'API_ERROR');
            return;
        }

        if (!tweet || !tweet.legacy) {
            this.ui.setButtonStatus(btn, 'failed', 'API_ERROR');
            return;
        }

        let user = tweet.core?.user_results?.result?.legacy || { screen_name: 'unknown', name: 'unknown' };
        let invalid = Utils.getInvalidChars();
        let datetime = out.match(/{date-time(-local)?:[^{}]+}/) ? out.match(/{date-time(?:-local)?:([^{}]+)}/)[1].replace(/[\\/|<>*?:"]/g, v => invalid[v] || '') : 'YYYYMMDD-hhmmss';
        let textLength = out.match(/{full-text:(\d+)}/) ? parseInt(out.match(/{full-text:(\d+)}/)[1], 10) : 999;

        let info = {
            'status-id': status_id, 'user-id': user.screen_name, 'fav-count': tweet.legacy.favorite_count || 0,
            'user-name': user.name.replace(/[\n\t\\/|<>*?:"]|[\u200b-\u200d\u2060\ufeff]|🔞/g, v => invalid[v] !== undefined ? invalid[v] : ''),
            'date-time': Utils.formatDate(tweet.legacy.created_at, datetime),
            'date-time-local': Utils.formatDate(tweet.legacy.created_at, datetime, true),
            'full-text': (tweet.legacy.full_text || '').replace(/\s*https:\/\/t\.co\/\w+/g, '').replaceAll(/\n+/g, '\n').replace(/[\n\t\\/|<>*?:"]|[\u200b-\u200d\u2060\ufeff]/g, v => invalid[v] !== undefined ? invalid[v] : '').substring(0, textLength)
        };

        let medias = tweet.legacy.extended_entities?.media || [];
        info['media-count'] = medias.length;

        if (index) {
            let idx = parseInt(index, 10) - 1;
            medias = medias[idx] ? [medias[idx]] : [];
        }

        if (medias.length > 0) {
            let tasksLeft = medias.length;

            medias.forEach((media, i) => {
                let mp4Variants = media.video_info?.variants?.filter(n => n.content_type === 'video/mp4') || [];
                info.url = media.type === 'photo'
                    ? media.media_url_https + ':orig'
                    : (mp4Variants.length > 0 ? mp4Variants.reduce((a, b) => (a.bitrate || 0) >= (b.bitrate || 0) ? a : b).url : media.video_info?.variants[0]?.url);

                if (!info.url) {
                    if (--tasksLeft === 0) this.ui.setButtonStatus(btn, 'failed', 'NO_URL');
                    return;
                }

                info.file = info.url.split('/').pop().split(/[:?]/)[0];
                info['file-name'] = info.file.split('.')[0];
                info['file-ext'] = info.file.split('.').pop();
                info['file-type'] = media.type.replace('animated_', '');
                info.index = index ? index : (i + 1);

                info.out = (out.replace(/\.?{file-ext}/, '') + ((medias.length > 1 || index) && !out.includes('{index}') && !out.includes('{file-name}') ? '-' + info.index : '') + '.{file-ext}')
                    .replace(/{([^{}:]+)(:[^{}]+)?}/g, (_, name) => info[name] != null ? info[name] : '');

                this.queue.add({
                    url: info.url, name: info.out,
                    onload: async () => {
                        if (--tasksLeft === 0) {
                            this.ui.setButtonStatus(btn, 'completed', '下载完成');
                            if (!is_exist) {
                                await this.storage.markDownloaded(status_id);
                            }
                        }
                    },
                    onerror: () => { tasksLeft = -1; this.ui.setButtonStatus(btn, 'failed', 'ERROR'); }
                });
            });
        } else {
            this.ui.setButtonStatus(btn, 'failed', 'MEDIA_NOT_FOUND');
        }
    }

    async handleScreenshotClick(btn, article) {
        if (btn.classList.contains('snapping')) return;
        btn.classList.add('snapping');
        btn.title = '截图中…';
        let undo = () => {};
        let undoMargin = () => {};
        try {
            undo = ScreenshotCapture.hideNodes(ScreenshotCapture.findRemov(article));
            undoMargin = ScreenshotCapture.fixMargin(article);
            const assets = await ScreenshotCapture.collect(article);
            undo(); undo = () => {};
            undoMargin(); undoMargin = () => {};
            const uri = await ScreenshotCapture.mergeImgs(assets);
            const a = document.createElement('a');
            let user = 'x';
            const userLink = article.querySelector('[data-testid="User-Name"] a[href^="/"]');
            if (userLink) {
                user = userLink.getAttribute('href').replace(/^\/+/, '').split('/')[0];
                user = user.replace(/[\\/:*?"<>|]/g, '_');
            }
            a.download = `${user}-${Date.now()}.png`;
            a.href = uri; a.click();
            const toBlob = u => fetch(u).then(r => r.blob());
            if (navigator.clipboard && typeof ClipboardItem !== 'undefined') {
                navigator.clipboard.write([new ClipboardItem({ 'image/png': toBlob(uri) })]).catch(() => {});
            }
        } catch (err) {
            console.error('[截图] 异常:', err);
            alert(`截图失败: ${err?.message ?? err}`);
        } finally {
            undo();
            undoMargin();
            btn.classList.remove('snapping');
            btn.title = '截图推文';
        }
    }
}

new TwitterMediaDownloaderApp().init();
